package models;

import helpers.Filter;
import helpers.Tripple;

import java.awt.Color;
import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;

import factories.BufferFactory;

/**
 * 3D Circular Hough Transform Model 
 * @author benjamin
 	This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
public class HoughTransformModel extends Model {
	private BufferedImage loadedImg;
	private BufferedImage filteredImg;
	private BufferedImage outputImg;
	private BufferedImage projectedHoughSpaceImg;
	private float[][][] accumBuffer;
	private ArrayList<Tripple> detectedCircles;
	private int minDetectionRadius = 10;
	private int maxDetectionRadius = 70;
	private float resultProximityMergeThreshold = 25.0f;
	private float percentageOfCircumferenceForDetectionThreshold = 0.7f;
	private float minimumSourcePixelIntensityPercentageThreshold = 0.66f;
	private float[] maximumAccumValue;
	private int padding = 0;
	/**
	 * Accessor for the hough space projection
	 * @return null if no transform has been made else an image buffer
	 */
	public BufferedImage getProjectedHoughSpaceImg() {
		return projectedHoughSpaceImg;
	}
	
	/**
	 * Accessor for the detected circle list
	 * @return arraylist of tripples if transformation has been made otherwise null
	 */
	public ArrayList<Tripple> getDetectedCircles() {
		return (detectedCircles != null) ? (ArrayList<Tripple>)detectedCircles.clone() : null;
	}
	
	/**
	 * Accessor for minimum detection radius
	 * @return minimum detection radius
	 */
	public int getMinDetectionRadius() {
		return minDetectionRadius;
	}

	/**
	 * Mutator for minimum detection radius 
	 * @param minDetectionRadius a positive number
	 */
	public void setMinDetectionRadius(int minDetectionRadius) {
		if (minDetectionRadius < 0) throw new IllegalArgumentException("Detection radius interval must start from a positive number");
		this.minDetectionRadius = minDetectionRadius;
	}

	/**
	 * Accessor for maximum detection radius
	 * @return maximum detection radius
	 */
	public int getMaxDetectionRadius() {
		return maxDetectionRadius;
	}

	/**
	 * Mutator for maximum detection radius
	 * @param maxDetectionRadius a positive number
	 */
	public void setMaxDetectionRadius(int maxDetectionRadius) {
		if (maxDetectionRadius < 0) throw new IllegalArgumentException("Detection radius interval must end at a positive number");		
		this.maxDetectionRadius = maxDetectionRadius;
	}

	/**
	 * Accessor for the proximity merge distance
	 * @return proximity merge distance
	 */
	public float getResultProximityMergeThreshold() {
		return (float)Math.sqrt(resultProximityMergeThreshold);
	}

	/**
	 * Mutator for the proximity merge distance
	 * @param resultProximityMergeThreshold
	 */
	public void setResultProximityMergeThreshold(float resultProximityMergeThreshold) {
		if (resultProximityMergeThreshold < 0) throw new IllegalArgumentException("Proximity Merge Threshold must be a positive number");
		this.resultProximityMergeThreshold = resultProximityMergeThreshold*resultProximityMergeThreshold;
	}

	/**
	 * Accessor for the % of circumference needed to classify a curve as a circle
	 * @return [0..1]
	 */
	public float getPercentageOfCircumferenceForDetectionThreshold() {
		return percentageOfCircumferenceForDetectionThreshold;
	}

	/**
	 * Mutator for the % of circumference needed to classify a curve as a circle
	 * @param percentageOfCircumferenceForDetectionThreshold a number in [0..1]
	 */
	public void setPercentageOfCircumferenceForDetectionThreshold(
			float percentageOfCircumferenceForDetectionThreshold) {
		if (percentageOfCircumferenceForDetectionThreshold < 0 || percentageOfCircumferenceForDetectionThreshold > 1.0f) 
			throw new IllegalArgumentException("Percentage of Circumference Threshold for Detection must be a positive percentage smaller or equal to 1");
		this.percentageOfCircumferenceForDetectionThreshold = percentageOfCircumferenceForDetectionThreshold;
	}

	/**
	 * Accessor for the minimum tolerated intensity to perform a transform
	 * @return value in [0..1]
	 */
	public float getMinimumSourcePixelIntensityPercentageThreshold() {
		return minimumSourcePixelIntensityPercentageThreshold;
	}

	/**
	 * Mutator for the minimum tolerated intensity to perform a transform
	 * @param minimumSourcePixelIntensityPercentageThreshold value in [0..1]
	 */
	public void setMinimumSourcePixelIntensityPercentageThreshold(
			float minimumSourcePixelIntensityPercentageThreshold) {
		if (minimumSourcePixelIntensityPercentageThreshold < 0 || minimumSourcePixelIntensityPercentageThreshold > 1.0f) 
			throw new IllegalArgumentException("Percentage of Source Pixel Intensity for Detection must be a positive percentage smaller or equal to 1");
		this.minimumSourcePixelIntensityPercentageThreshold = minimumSourcePixelIntensityPercentageThreshold;
	}

	/**
	 * Loads a source image (and precompute the appropriate filtering required for edge detection
	 * @param filename
	 * @throws IOException if image cannot be loaded
	 */
	public void loadSourceImage(String filename) throws IOException{
		loadedImg = BufferFactory.createBuffer(filename);
		if (loadedImg == null) 
			throw new IOException("Cannot load file. This may be due to an unsupported file type or file corruption");
		Filter fEdge = factories.FilterFactory.createEdgeFilter();
		Filter fSharpen = factories.FilterFactory.createSharpenFilter();
		filteredImg = fEdge.apply(fSharpen.apply(loadedImg));
		outputImg = null;
		int maxImageDim = Math.max(loadedImg.getHeight(), loadedImg.getWidth());
		padding = (int)(0.3f*maxImageDim);
		this.updateObservers("LOADEDIMG", this);
	}
	
	/**
	 * Accessor for filtered image
	 * @return filtered image
	 */
	public BufferedImage getFilteredImg() {
		return filteredImg;
	}

	/**
	 * Accessor for output image (with circles detected)
	 * @return output image
	 */
	public BufferedImage getOutputImg() {
		return outputImg;
	}

	/**
	 * Accessor for the originally loaded image
	 * @return original image
	 */
	public BufferedImage getLoadedImg() {
		return loadedImg;
	}

	/**
	 * Performs the well known circular hough transform over all pixels that meets the set intensity threshold. The transform
	 * maps onto a 3 dimensional space [a,b,radius] where radius is in [minradius..maxradius] 
	 */
	public void detectCircles(){
		if (loadedImg == null){ 
			this.updateObservers("DETECTCOMPL", this);
			return;
		}
		//setup the accumulation buffer
		accumBuffer = new float[maxDetectionRadius - minDetectionRadius + 1][filteredImg.getHeight()+2*padding][filteredImg.getWidth()+2*padding];
		detectedCircles = new ArrayList<Tripple>();
		maximumAccumValue = new float[maxDetectionRadius - minDetectionRadius + 1];
		for (int y = 0; y < filteredImg.getHeight(); ++y){
			this.updateObservers("PROGRESSUPDATE",new Float(y/(float)filteredImg.getHeight()*100));
			for (int x = 0; x < filteredImg.getWidth(); ++x)
				for (int r = minDetectionRadius; r <= maxDetectionRadius; ++r){ //draw circles over the entire range [minradius..maxradius]
					int pixel = filteredImg.getRaster().getPixel(x, y, new int[1])[0];
					if (pixel > minimumSourcePixelIntensityPercentageThreshold*Byte.MAX_VALUE){
						plotCircleToBuffer(padding+x,padding+y,r);
					}
				}
		}
		//project the houghspace to 2D:
		constructProjectionOfHoughSpace();
		//plot circles onto output image:
		outputImg = BufferFactory.createColourBufferFromGrey(loadedImg);
		projectedHoughSpaceImg = BufferFactory.createColourBufferFromGrey(projectedHoughSpaceImg);
		Graphics g = outputImg.getGraphics();
		Graphics gH = projectedHoughSpaceImg.getGraphics();
		g.setColor(Color.YELLOW);
		gH.setColor(Color.YELLOW);
		for (int i = 0; i < detectedCircles.size(); ++i){
			Tripple c = detectedCircles.get(i);
			g.drawOval(c.getX()-c.getR()-padding, c.getY()-c.getR()-padding, c.getR()*2, c.getR()*2);
			gH.drawOval(c.getX()-c.getR()-padding, c.getY()-c.getR()-padding, c.getR()*2, c.getR()*2);
			g.drawLine(c.getX()-3-padding, c.getY()-padding, c.getX()+3-padding, c.getY()-padding);
			g.drawLine(c.getX()-padding, c.getY()-3-padding, c.getX()-padding, c.getY()+3-padding);
		}
		
		this.updateObservers("DETECTCOMPL", this);
	}
	
	/**
	 * Adds a 1 to the accumulation buffer in hough space and detects possible circle matches (this saves us the effort of 
	 * running a N^3 loop later on). Merges possible hits within a predefined tolerance to increase accuracy.
	 * @param x 
	 * @param y
	 * @param z the radius slice z = r-minRadius and z in [0, maxradius-minradius]
	 */
	private void plotPixel(int x, int y, int z){
		int currentRadius = z + minDetectionRadius;
		if (x >= 0 && y >= 0 && x < loadedImg.getWidth()+2*padding && y < loadedImg.getHeight()+2*padding){
			if (++accumBuffer[z][y][x] >= percentageOfCircumferenceForDetectionThreshold*2*Math.PI*currentRadius){
				boolean canAdd = true;
				for (int i = 0; i < detectedCircles.size(); ++i){
					Tripple c = detectedCircles.get(i);
					if (distanceSq(x,y,c.getX(),c.getY()) <= resultProximityMergeThreshold){
						canAdd = false;
						if (accumBuffer[z][y][x] > accumBuffer[z][c.getY()][c.getX()]){ //local maximums:
							c.setX(x);
							c.setY(y);
						}
						break;
					}
				}
				if (canAdd)
					detectedCircles.add(new Tripple(x, y, currentRadius));
			}
			maximumAccumValue[z] = Math.max(maximumAccumValue[z], accumBuffer[z][y][x]);
		}
	}
	
	/**
	 * Plots a circle using the well known Midpoint Circle plotting algorithm
	 * which is a derivative of Bresenham's Line Drawing algorithm.
	 * Code is adapted from http://en.wikipedia.org/wiki/Midpoint_circle_algorithm
	 * @param x0 center of circle
	 * @param y0 center of circle
	 * @param radius 
	 */
	private void plotCircleToBuffer(int x0, int y0, int radius)
	{
	  assert (radius >= minDetectionRadius && radius <= maxDetectionRadius);
	  int z = radius - minDetectionRadius;
	  int f = 1 - radius;
	  int ddF_x = 1;
	  int ddF_y = -2 * radius;
	  int x = 0;
	  int y = radius;
	 
	  plotPixel(x0, y0 + radius, z);
	  plotPixel(x0, y0 - radius, z);
	  plotPixel(x0 + radius, y0, z);
	  plotPixel(x0 - radius, y0, z);
	 
	  while(x < y)
	  {
	    // ddF_x == 2 * x + 1;
	    // ddF_y == -2 * y;
	    // f == x*x + y*y - radius*radius + 2*x - y + 1;
	    if(f >= 0) 
	    {
	      y--;
	      ddF_y += 2;
	      f += ddF_y;
	    }
	    x++;
	    ddF_x += 2;
	    f += ddF_x;    
	    plotPixel(x0 + x, y0 + y, z);
	    plotPixel(x0 - x, y0 + y, z);
	    plotPixel(x0 + x, y0 - y, z);
	    plotPixel(x0 - x, y0 - y, z);
	    plotPixel(x0 + y, y0 + x, z);
	    plotPixel(x0 - y, y0 + x, z);
	    plotPixel(x0 + y, y0 - x, z);
	    plotPixel(x0 - y, y0 - x, z);
	  }
	}
	
	/**
	 * Constructs a simple 2D "projected" visualization of the hough space
	 */
	private void constructProjectionOfHoughSpace(){
		projectedHoughSpaceImg = new BufferedImage(loadedImg.getWidth(),loadedImg.getHeight(),BufferedImage.TYPE_BYTE_GRAY);
		int numRadiusSlices = maxDetectionRadius - minDetectionRadius +1;
		float norm = 0;
		for (int y = 0; y < filteredImg.getHeight()+2*padding; ++y)
			for (int x = 0; x < filteredImg.getWidth()+2*padding; ++x){
				for (int r = minDetectionRadius+1; r <= maxDetectionRadius; ++r){
					int z = r-minDetectionRadius;
					accumBuffer[0][y][x] = Math.max(accumBuffer[0][y][x] ,				//save all the brightest points
							accumBuffer[r-minDetectionRadius][y][x]/maximumAccumValue[z]*Byte.MAX_VALUE);
					norm = Math.max(norm, accumBuffer[0][y][x]);
				}
			}
		//construct a pixel buffer
		for (int y = 0; y < filteredImg.getHeight(); ++y)
			for (int x = 0; x < filteredImg.getWidth(); ++x){
				int pixel[] = {(int)(accumBuffer[0][y+padding][x+padding]/norm*Byte.MAX_VALUE)};
				projectedHoughSpaceImg.getRaster().setPixel(x, y, pixel);
			}
	}
	
	/**
	 * Gets the square of the distance between points
	 * @param x1
	 * @param y1
	 * @param x2
	 * @param y2
	 * @return d^2 between points
	 */
	private static float distanceSq(int x1,int y1, int x2, int y2){
		return (x1-x2)*(x1-x2) + (y1-y2)*(y1-y2);
	}
}
